跳到主要内容

5.1-多线程

Create by fall on 22 Nov 2020 Recently revised in 20 Sep 2023

浏览器多线程

多线程的方法分为以下三种

  • worklet(渲染时可使用的钩子)
  • Service worker(网络代理钩子)
  • Web worker(减轻主线程工作量)

Worklet 是浏览器渲染流中的钩子,可以让我们有浏览器渲染线程中底层的权限,比如样式和布局。

Service worker 是浏览器和网络间的代理。通过拦截文档中发出的请求,service worker 可以直接请求缓存中的数据,达到离线运行的目的。

Web worker 是通常目的是让我们减轻主线程中的密集处理工作的脚本。

  • 协程是一种比线程更加轻量级的存在,协程可以看成是跑在线程上的任务,一个线程可以存在多个协程,但是同时只能执行一个协程,如果 A 协程启动 B 协程,A 为 B 的父协程;
  • 协程不被操作协同内核所管理,而完全由程序所控制,这样可以实现性能提升;

Web Worker

一套 API ,允许一段 JavaScript 运行在主线程之外的线程。

Web Worker 定义了两类工作线程,专用线程(Dedicated Worker)和共享线程(Shared Worker)

  • 专用线程:只能为一个页面所使用
  • 共享线程:被多个页面共享

创建 Worker

创建 worker 和向 worker 中发送信息

let worker = new Worker('worker.js')
// worker.js 就是线程需要执行的任务,由于不能读取本地文件,这个文件必须来自网络
// worker.js 中的代码
var i = 0;
function timedCount(){
i = i+1;
postMessage(i); // worker内的独有函数,负责和主页面通信
setTimeout(timedCount, 1000);
}
timedCount();

通过URL.createObjectURL() 创建 URL 对象,可以实现 worker 的内嵌

var myTask = `
var i = 0;
function timedCount(){
i = i+1;
postMessage(i);
setTimeout(timedCount, 1000);
}
timedCount();
`
var blob = new Blob([myTask])// 将myTask转换为二进制,注意这个中括号
var myWorker = new Worker(window.URL.createObjectURL(blob));
// 会开启一个线程并且执行 myTask中的代码

线程通信

线程通信通过两种方式实现

  • message 事件,window.addEventListener('message',fun)
  • postMessage

传递的数据是通过拷贝,而不是共享来完成的,只能单纯的传递数据。意味着传递给 Worker 处理的对象需要经过序列化,另一端需要反序列化。并且传递的数据是拷贝生成的副本,修改不会影响另一端接受的数据。

const complexTask = `
onmessage = funciton(e){
const data = e.data
data.push('hello')
console.log('worker线程的data')
console.log(data)
postMessage(data)
}`
const blob = new Blob([myTask])
var myWorker = new Worker(window.URL.createObjectURL(blob))
myWorker.onmessage = function(e){
const data = e.data
console.log('page:',data)
console.log('arr:',arr)
}
const arr = [1,2,3]
myWorker.postMessage(arr)

Service Worker

Service Worker 可以使你的应用先访问本地缓存资源,所以在离线状态时,在没有通过网络接收到更多的数据前,仍可以提供基本的功能

Service Worker 可以拦截当前网站所有的请求,进行判断(需要编写相应的判断程序),如果需要向服务器发

service Worker 脱离于主线程之外的线程,用于解决性能问题,一些消耗时间,并且复杂的计算通过 service Worker 进行计算。

service worker 的特点

  • 独立的线程,有自己的worker content,不能和主线程直接通信,必须通过消息完成。
  • 不能使用诸如 alert()confirm() 方法
  • 一旦被 install 就会一直存在,除非被 uninstall
  • 不需要时,会进行休眠
  • 可以拦截代理请求和返回,缓存的文件可以被读取到
  • 离线的内容,开发者可控
  • 可以向客户端推送信息
  • 分配给 Worker 线程运行的脚本文件,必须与主线程的脚本文件同源。
  • 不能直接操作 DOM
  • 不能操作本地磁盘中的数据
  • 必须是 https 协议,或者是 localhost 环境下才能工作

分配给 Worker 线程运行的脚本文件,必须与主线程的脚本文件同源。

使用步骤

  • service worker URL 通过 serviceWorkerContainer.register() 用于获取和注册
  • 注册成功,service worker 就在 ServiceWorkerGlobalScope 环境中运行,该环境与主线程独立,同时没有访问 DOM 的能力
  • 可以进行处理事件
  • service worker 控制的页面打开后会尝试去安装 service worker,最先发送给 service worker 的事件是安装事件(在该事件内可以开始进行填充 IndexDB 和缓存站点资源),让所有资源可以离线访问。
  • oninstall 事件处理程序执行完成之后,就可以认为 service worker 已经安装完成了
  • 然后是激活,service worker 安装完成之后,会收到激活事件。 onactivate 主要用途是清理先前版本的 service worker 脚本中使用的资源。
  • Service Worker 现在可以控制页面,但仅在 register() 成功后打开的页面,也就是说,页面起始于有没有 service worker 并且在接下来的生命周期维持这个状态,所以不得不重新加载让 service worker 重新获得控制。

支持的事件

installactivatemessagefetchsyncpush

注册 service worker

if('serviceWorker' in navigator){ // 确保浏览器支持 service worker
// 注册站点的 service worker
// 路径为相对于 origin 的文件路径,如 https://mdn.github.io/sw-test/sw.js 引入应该为 /sw-test/sw.js
// scope 为选填的内容
navigator.serviceWorker.register('/sw-test/sw.js',{scope:'/sw-test/'}).then(function(reg){
// worked
console.log('成功')
}).catch(function(error){
// failed
console.log('Registration failed with '+ error)
})
}

单个 service worker 可以控制很多页面,每个 scope 里的页面加载完的时候,安装在页面的 service worker 可以控制它,小心 service worker 中的全局变量,每个页面不会有自己独有的 worker。

安装和激活

this.addEventListener('install',function(event){
event.waitUntil(
caches.open('v1').then(function(cache){
return cache.addAll([
'/sw-test/',
'/sw-test/index.html',
'/sw-test/style.css',
'/sw-test/app.js',
'/sw-test/star-wars-logo.jpg',
])
})
)
})

localStorage 它是同步的,不允许在 service workers 内使用。

现在已经有了站点内容的缓存,你可以通过 fetch 事件告诉 service worker 让它用这些缓存内容。

所有被 service woker 控制的资源每次被请求到时,都会触发 fetch 事件吗

使用

基本使用

调用 Worker() 构造函数,会构建一个 Worker 线程

var jsUrl = 'worker.js'
var options ={ name : 'myWorker' }
var myWorker = new Worker(jsUrl, options);

接受信息

worker.postMessage('hello world')
// 主线程通过 worker.postMessage 向 worker 发消息
worker.postMessage({method: 'echo', args: ['Work']});

worker.onmessage() 进行接收数据

worker.onmessage() // 用于接收信息
worker.onmessage = function (event) {
console.log('Received message ' + event.data);
doSomething();
}

function doSomething() {
// 执行任务
worker.postMessage('Work done!');
}

使用完成后进行关闭

worker.terminate()

API

浏览器原生提供Worker()构造函数,用来供主线程生成 Worker 线程。

var myWorker = new Worker(jsUrl, options);
// 第一个参数是脚本的网址,且必须遵守同源政策,且只能加载 JS 脚本(必需)
// 第二个参数是配置对象,该对象可选。它的一个作用就是指定 Worker 的名称,用来区分多个 Worker 线程。
// 示例
var myWorker = new Worker('worker.js', { name : 'myWorker' });

实例方法

let worker = new Worker('worker.js', { name : 'myWorker' });
worker.onerror// 指定 error 事件的监听函数。
worker.onmessage// message 事件的监听函数,发送过来的数据在Event.data属性中。
worker.onmessageerror// 指定 messageerror 事件的监听函数。发送的数据无法序列化成字符串时,会触发这个事件。
worker.postMessage()// 向 Worker 线程发送消息。
worker.terminate()// 立即终止 Worker 线程。

县城上的API

Web Worker 有自己的全局对象,不是主线程的window,而是一个专门为 Worker 定制的全局对象。因此定义在window上面的对象和方法不是全部都可以使用。

self.name           // worker 的名字。该属性只读,由构造函数指定。
self.onmessage // 指定message事件的监听函数。
self.onmessageerror
// 指定 messageerror 事件的监听函数。发送的数据无法序列化成字符串时,会触发这个事件。
self.close() //关闭 Worker 线程。
self.postMessage() //向产生这个 Worker 线程发送消息。
self.importScripts()//加载 JS 脚本。

Worker 线程

// self代表子线程自身,即子线程的全局对象。
self.addEventListener('message',function(ev){
self.postMessage('you said', ev.data)
},false)

根据主线程发过来的数据,worker线程可以调用不同的方法

self.addEventListener('message', function (e) {
var data = e.data;
switch (data.cmd) {
case 'start':
self.postMessage('WORKER STARTED: ' + data.msg);
break;
case 'stop':
self.postMessage('WORKER STOPPED: ' + data.msg);
self.close(); // Terminates the worker.
break;
default:
self.postMessage('Unknown command: ' + data.msg);
};
}, false);

加载脚本

如果想要在 service 内部加载脚本,可以使用 importScript()

importScripts('script1.js');
// 可以同时加载多个脚本
importScripts('script1.js', 'script2.js');

错误处理

worker.onerror(function (event) {
console.log([
'ERROR: Line ', e.lineno, ' in ', e.filename, ': ', e.message
].join(''));
});
// 或者
worker.addEventListener('error', function (event) {
// ...
});

数据通信

处理 service worker 时,浏览器内部的运行机制是,先将通信内容串行化,然后把串行化后的字符串发给 Worker,后者再将它还原。

通信内容,可以是文本,也可以是对象。但并不是普通的传址,而是拷贝。

var uInt8Array = new Uint8Array(new ArrayBuffer(10));
for (var i = 0; i < uInt8Array.length; ++i) {
uInt8Array[i] = i * 2; // [0, 2, 4, 6, 8,...]
}
worker.postMessage(uInt8Array);

// Worker 线程
self.onmessage = function (e) {
var uInt8Array = e.data;
postMessage('Inside worker.js: uInt8Array.toString() = ' + uInt8Array.toString());
postMessage('Inside worker.js: uInt8Array.byteLength = ' + uInt8Array.byteLength);
};

拷贝方式发送二进制数据,会造成性能问题。大文件传输的话,需要额外使用线程去解决,并且进行传输。了解决这个问题,JavaScript 允许主线程把二进制数据直接转移给子线程,但是一旦转移,主线程就无法再使用这些二进制数据了,这是为了防止出现多个线程同时修改数据的麻烦局面。这种转移数据的方法,叫做Transferable Objects。这使得主线程可以快速把数据交给 Worker,对于影像处理、声音处理、3D 运算等就非常方便了,不会产生性能负担。

// Transferable Objects 格式
worker.postMessage(arrayBuffer, [arrayBuffer]);

// 例子
var ab = new ArrayBuffer(1);
worker.postMessage(ab, [ab]);

功能

实现 webworker 和 js 在同一个界面

嵌入网页的脚本,指定<script>标签的type属性是一个浏览器不认识的值,然后,读取这一段嵌入页面的脚本,用 Worker 来处理。

<script id="worker" type="app/worker">
addEventListener('message', function () {
postMessage('some message');
}, false);
</script>

先将嵌入网页的脚本代码,转成一个二进制对象,然后为这个二进制对象生成 URL,再让 Worker 加载这个 URL。这样就做到了,主线程和 Worker 的代码都在同一个网页上面。

var blob = new Blob([document.querySelector('#worker').textContent]);
var url = window.URL.createObjectURL(blob);
var worker = new Worker(url);
worker.onmessage = function (e) {
// e.data === 'some message'
};

worker 实现轮询

需要查看服务器状态,以便第一时间获取状态的改变。

以下代码实现Worker 每秒钟轮询一次数据,然后跟缓存做比较。如果不一致,就说明服务端有了新的变化,因此就要通知主线程。

function createWorker(f) {
var blob = new Blob(['(' + f.toString() +')()']);
var url = window.URL.createObjectURL(blob);
var worker = new Worker(url);
return worker;
}
var pollingWorker = createWorker(function (e) {
var cache;
function compare(new, old) { }; // {...}
setInterval(function () {
fetch('/my-api-endpoint').then(function (res) {
var data = res.json();
if (!compare(data, cache)) {
cache = data;
self.postMessage(data);
}
})
}, 1000)
})
pollingWorker.onmessage = function () {
// render data
}
pollingWorker.postMessage('init');

嵌套worker

Worker 线程内部还能再新建 Worker 线程(目前只有 Firefox 浏览器支持)。下面的例子是将一个计算密集的任务,分配到10个 Worker。

var worker = new Worker('worker.js');
worker.onmessage = function (event) {
document.getElementById('result').textContent = event.data;
};

下面代码 将在 Worker 线程内部新建了10个 Worker 线程,并且依次向这10个 Worker 发送消息,告知了计算的起点和终点。

// worker.js

// settings
var num_workers = 10;
var items_per_worker = 1000000;

// start the workers
var result = 0;
var pending_workers = num_workers;
for (var i = 0; i < num_workers; i += 1) {
var worker = new Worker('core.js');
worker.postMessage(i * items_per_worker);
worker.postMessage((i + 1) * items_per_worker);
worker.onmessage = storeResult;
}
// handle the results
function storeResult(event) {
result += event.data;
pending_workers -= 1;
if (pending_workers <= 0)
postMessage(result); // finished!
}

需要执行的代码

// core.js
var start;
onmessage = getStart;
function getStart(event) {
start = event.data;
onmessage = getEnd;
}
var end;
function getEnd(event) {
end = event.data;
onmessage = null;
work();
}
function work() {
var result = 0;
for (var i = start; i < end; i += 1) {
// perform some complex calculation here
result += 1;
}
postMessage(result);
close();
}

参考文章

作者链接
考拉海购前端团队https://juejin.cn/post/6844903496550989837
MDN官方文档https://developer.mozilla.org/zh-CN/docs/Web/API/Service_Worker_API/Using_Service_Workers
张驰Terryhttps://juejin.cn/post/6979758006711877640